iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 22

以 Go + Echo 打造部落格|第 6 篇:文章內頁開張啦!

  • 分享至 

  • xImage
  •  

這一集,我們要幫部落格把單篇文章的「內頁」做出來!就像你點進去一篇 PTT 或 Dcard 文,會看到完整內容那樣。還有,我們會學會把「草稿」藏起來,不給別人看,以及弄個帥氣的 404 找不到頁面!

💻 逐步拆解(先看懂骨架!)

A. 資料庫:用代號(Slug)抓「已發佈」文章

文章在資料庫裡都有一個像「獨家代號」的東西,我們叫它 Slug(就是網址上 /posts/ 後面那串英文字)。

我們要寫一個超級專用的抓耙子,它很龜毛,只會抓:

代號要對!

文章狀態要是「已發佈」(published)才行!
// PostRepo 就是資料庫管理員。
// 這個方法只負責抓「已發佈」的文章,草稿(draft)?不給過!
func (r *PostRepo) GetPublishedBySlug(ctx context.Context, slug string) (domain.Post, error) {
    // 厲害的 SQL 查詢就在完整版裡!
	return domain.Post{}, nil
}

B. 處理器(Handler):文章內容大變身!🎨

我們寫文章用的是 Markdown(像純文字但可以加粗體、列表),但瀏覽器只看得懂 HTML。所以,Handler 要做兩件大事:

拿 Slug 抓文章。

用 goldmark 把 Markdown 變成 HTML。

用 bluemonday 像**「內容安全警衛」一樣,把 HTML 裡危險**的東西(例如偷塞病毒碼)過濾掉!

最後把乾淨的 HTML 塞進模板!
// PostPage 就是部落格內頁的總指揮官。
func (h *FrontPostsHandler) PostPage(c echo.Context) error {
	slug := c.Param("slug") // 從網址抓到 Slug 代號!
	// 拿代號 → 抓文章 → 轉 Markdown → 過濾 → 塞進模板!
	// 記得,找不到或草稿,通通丟 404 錯誤!
	return c.Render(http.StatusOK, "pages/post_show.html", data)
}

C. 模板骨架:文章內頁 post_show.html

模板就是文章的外觀設計圖,它會負責把標題、日期和處理好的乾淨文章內容放進去。

{{ define "pages/post_show" -}}
{{ template "layouts/base" . }}

{{ define "title" -}}{{ .Title }}{{ end }} {{ define "content" -}}
  {{- end }}
{{- end }}

D. 404 頁與錯誤處理:走丟了別怕!🧭

當使用者來亂逛、輸入一個不存在的網址,或者想偷看你的「草稿」時,我們不能讓網頁當掉!要優雅地告訴他:「抱歉,這頁面走丟了。」

D. 404 頁與錯誤處理:走丟了別怕!🧭

當使用者來亂逛、輸入一個不存在的網址,或者想偷看你的「草稿」時,我們不能讓網頁當掉!要優雅地告訴他:「抱歉,這頁面走丟了。」

E. 路由(Route):設定交通規則 🚦

最後,要告訴伺服器交通規則:看到 /posts/後面一串字 這種網址,就要交給 front.PostPage 這個總指揮官去處理!

e.GET("/posts/:slug", front.PostPage) // :slug 就是那個文章的獨家代號

🛠️ 合併完整內容(直接貼上就能跑!)

下面是把骨架填滿肉的程式碼,你可以直接複製貼到對應檔案裡!

  1. Repository:只抓 published 文章

在 internal/storage/postgres/posts.go 裡,關鍵就在這行 WHERE slug = $1 AND status = 'published' LIMIT 1:

// 略...

func (r *PostRepo) GetPublishedBySlug(ctx context.Context, slug string) (domain.Post, error) {
	var p domain.Post
    // ⬇️ 這裡!只抓 slug 對,**而且** 狀態是 published 的文章!
	err := r.Pool.QueryRow(ctx, `
SELECT id, author_id, title, slug, summary, content_md, cover_image, status, published_at, created_at, updated_at
FROM posts
WHERE slug = $1 AND status = 'published'
LIMIT 1
`, slug).Scan(
		&p.ID, &p.AuthorID, &p.Title, &p.Slug, &p.Summary, &p.ContentMD,
		&p.CoverImage, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
	)
    // 略... 找不到就回傳 ErrNotFound
	if errors.Is(err, pgx.ErrNoRows) {
		return p, ErrNotFound
	}
	return p, err
}
  1. Handler:Markdown 轉 HTML + 安全過濾 (⭐重點)

在 internal/http/handlers/front_posts.go 裡,這段是整個部落格內頁的靈魂!

// 略...

// ⭐️ 全域變數:只跑一次,設定好我們的 Markdown 轉換器與安全警衛!
var (
	mdParser = goldmark.New() // Markdown 翻譯機
	// UGCPolicy:安全警衛,專門過濾使用者發表的內容,把危險的標籤和屬性清掉!
	sanitizer = bluemonday.UGCPolicy()
)

func (h *FrontPostsHandler) PostPage(c echo.Context) error {
	slug := c.Param("slug")

	p, err := h.Repo.GetPublishedBySlug(c.Request().Context(), slug)
	if err != nil {
		// 沒找到文章(包含它只是草稿),就手動丟出 404 錯誤,讓後面的 Error Handler 接手!
		return echo.NewHTTPError(http.StatusNotFound, "post not found")
	}

	// 🎨 內容大變身!
	var htmlBuf []byte
	// 1. Markdown 轉成 HTML
	if err := mdParser.Convert([]byte(p.ContentMD), &htmlBuf); err != nil {
		return c.String(http.StatusInternalServerError, "render markdown failed")
	}
	// 2. 安全警衛過濾!
	safe := sanitizer.SanitizeBytes(htmlBuf)
	// 3. 告訴 Go 模板:「這段 HTML 已經檢查過,很安全!」
	contentHTML := template.HTML(string(safe)) 

	// 略... 準備資料給模板
	data := map[string]any{
		"Title": p.Title,
		// ... 略
		"Post": p,
		"ContentHTML": contentHTML, // 塞入乾淨的 HTML 內容
	}
	return c.Render(http.StatusOK, "pages/post_show.html", data)
}
  1. 模板:文章內頁 post_show.html

在 web/templates/pages/post_show.html,我們用 {{ .ContentHTML }} 顯示文章內容:

{{ define "pages/post_show" -}}
{{ template "layouts/base" . }}

{{ define "title" -}}{{ .Title }}{{ end }}

{{ define "content" -}}
  <article class="prose max-w-none">
    <header class="mb-6">
      <h1 class="text-3xl font-bold mb-1">{{ .Title }}</h1> <div class="text-slate-500 text-sm">發佈:{{ .PublishedAt }}</div> </header>

    <div class="prose prose-slate">
      {{ .ContentHTML }} 
    </div>

    <footer class="mt-8">
      <a href="/" class="inline-block rounded border bg-white px-3 py-1 hover:bg-slate-50">← 回首頁</a>
    </footer>
  </article>
{{- end }}

{{- end }}
  1. 404 頁 404.html

在 web/templates/pages/404.html,設計一個超有禮貌的走丟頁面:

{{ define "pages/404" -}}
{{ template "layouts/base" . }}

{{ define "title" -}}頁面不存在{{ end }}

{{ define "content" -}}
  <section class="grid place-items-center text-center py-16">
    <div class="grid gap-3">
      <div class="text-7xl font-bold text-slate-300">404</div>
      <h1 class="text-2xl font-semibold">這頁面走丟了 😢</h1>
      <p class="text-slate-600">可能文章是草稿、被刪除,或你輸入的網址有誤。</p>
      <a href="/" class="mx-auto inline-block rounded border bg白 px-3 py-1 hover:bg-slate-50">回首頁</a>
    </div>
  </section>
{{- end }}

{{- end }}
  1. 路由與 Error Handler (在 cmd/server/main.go 裡)

這是讓 404 模板能動起來的關鍵設定:

// 略...

// 自訂 404:如果是 404 錯誤,就改用我們的模板頁!
e.HTTPErrorHandler = func(err error, c echo.Context) {
	var he *echo.HTTPError
    // 檢查是不是 Echo 丟出的 404 錯誤
	if errors.As(err, &he) && he.Code == http.StatusNotFound {
		data := map[string]any{
			"Title": "頁面不存在",
			"SiteName": site,
            // ... 略
		}
        // 如果是 API 呼叫(例如用 curl),就回傳 JSON 格式
		if strings.Contains(c.Request().Header.Get("Accept"), "application/json") {
			_ = c.JSON(http.StatusNotFound, echo.Map{"error": "not found"})
			return
		}
        // 🌟 一般網頁瀏覽,就渲染我們的 404 模板!
		_ = c.Render(http.StatusNotFound, "pages/404.html", data)
		return
	}
    // 其他非 404 錯誤,就交給 Echo 預設的處理方式
	e.DefaultHTTPErrorHandler(err, c)
}
  1. 測試區(用 curl 驗收成果!)

啟動你的 Go 程式後,可以試試看這些指令:

# 1. 建立一篇 **已發佈** 文章
curl -sX POST http://localhost:1323/api/admin/posts \
  -H "Content-Type: application/json" \
  -d '{
    "author_id": 1,
    "title": "公測文一號",
    "slug": "public-1",
    "summary": "這是公開測試文章",
    "content_md": "# Hello\n內文 **Markdown** 測試",
    "status": "published"
  }' | jq .

# 2. 建立一篇 **草稿** (只有自己看得到!)
curl -sX POST http://localhost:1323/api/admin/posts \
  -H "Content-Type: application/json" \
  -d '{
    "author_id": 1,
    "title": "秘密草稿",
    "slug": "draft-1",
    "content_md": "還在寫...",
    "status": "draft"
  }' | jq .

# 3. 測試 **已發佈** 內頁 → 應該回傳 200 OK!
curl -i http://localhost:1323/posts/public-1 | head -n 15

# 4. 測試 **草稿** 內頁 → 應該回傳 404 找不到!
curl -i http://localhost:1323/posts/draft-1 | head -n 15

# 5. 測試 **不存在** 的文章 → 當然也是 404!
curl -i http://localhost:1323/posts/not-exist | head -n 15

💥 常見坑(排雷小幫手)🧯

忘了裝套件 → Markdown 轉換失敗
 → 跑 go get + go mod tidy。

直接把 Markdown 的 HTML 丟進模板 → 有風險
 → 一定要先用 bluemonday.UGCPolicy() 過濾,再 template.HTML。

404 沒出現自訂頁面
 → 確認 e.HTTPErrorHandler 有換成你的版本。

strings 未 import
 → 在 404 handler 用到了 strings.Contains,記得 import "strings"。

時區顯示怪怪的
 → 統一用台北時區 Asia/Taipei (+08:00)。


📝 小結

這集我們讓部落格變得更像一個真正的網站了!

現在,點進文章會看到單獨的內頁,網址是 /posts/文章代號。

草稿會被好好保護,別人偷看會看到 404 頁!

文章內容經過 Markdown → HTML → 安全檢查,超安心!

下一步預告:我們已經有文章了,但誰都能亂發文嗎?當然不行!第 7 集,我們要來做 Session 登入,把後台用密碼鎖起來,這樣就只有你能發文和預覽草稿囉!敬請期待!💪


上一篇
以 Go + Echo 打造部落格|第 5 篇:文章列表與分頁
下一篇
以 Go + Echo 打造部落格|第 7 集:把後台上鎖啦!
系列文
Golang x Echo 30 天:零基礎GO , 後端入門24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言